----------------------------------------------------------------------------
-- @author koniu &lt;gkusnierz@gmail.com&gt;
-- @copyright 2008 koniu
-- @release v3.4.13
----------------------------------------------------------------------------

-- Package environment
local pairs = pairs
local table = table
local type = type
local string = string
local pcall = pcall
local capi = { screen = screen,
               awesome = awesome,
               dbus = dbus,
               widget = widget,
               wibox = wibox,
               image = image,
               timer = timer }
local button = require("awful.button")
local util = require("awful.util")
local bt = require("beautiful")
local layout = require("awful.widget.layout")

--- Notification library
module("naughty")

--- Naughty configuration - a table containing common popup settings.
-- @name config
-- @field padding Space between popups and edge of the workarea. Default: 4
-- @field spacing Spacing between popups. Default: 1
-- @field icon_dirs List of directories that will be checked by getIcon()
--   Default: { "/usr/share/pixmaps/", }
-- @field icon_formats List of formats that will be checked by getIcon()
--   Default: { "png", "gif" }
-- @field default_preset Preset to be used by default.
--   Default: config.presets.normal
-- @field notify_callback Callback used to modify or reject notifications.
--   Default: nil
--   Example:
--      naughty.config.notify_callback = function(args)
--          args.text = 'prefix: ' .. args.text
--          return args
--      end
-- @class table

config = {}
config.padding = 4
config.spacing = 1
config.icon_dirs = { "/usr/share/pixmaps/", }
config.icon_formats = { "png", "gif" }
config.notify_callback = nil


--- Notification Presets - a table containing presets for different purposes
-- Preset is a table of any parameters available to notify()
-- You have to pass a reference of a preset in your notify() call to use the preset
-- At least the default preset named "normal" has to be defined
-- The presets "low", "normal" and "critical" are used for notifications over DBUS
-- @name config.presets
-- @field low The preset for notifications with low urgency level
-- @field normal The default preset for every notification without a preset that will also be used for normal urgency level
-- @field critical The preset for notifications with a critical urgency level
-- @class table

config.presets = {
    normal = {},
    low = {
        timeout = 5
    },
    critical = {
        bg = "#ff0000",
        fg = "#ffffff",
        timeout = 0,
    }
}

config.default_preset = config.presets.normal

-- DBUS Notification constants
urgency = {
    low = "\0",
    normal = "\1",
    critical = "\2"
}

--- DBUS notification to preset mapping
-- @name config.mapping
-- The first element is an object containing the filter
-- If the rules in the filter matches the associated preset will be applied
-- The rules object can contain: urgency, category, appname
-- The second element is the preset

config.mapping = {
    {{urgency = urgency.low}, config.presets.low},
    {{urgency = urgency.normal}, config.presets.normal},
    {{urgency = urgency.critical}, config.presets.critical}
}

-- Counter for the notifications
-- Required for later access via DBUS
local counter = 1

-- True if notifying is suspended
local suspended = false

--- Index of notifications. See config table for valid 'position' values.
-- Each element is a table consisting of:
-- @field box Wibox object containing the popup
-- @field height Popup height
-- @field width Popup width
-- @field die Function to be executed on timeout
-- @field id Unique notification id based on a counter
-- @name notifications[screen][position]
-- @class table

notifications = { suspended = { } }
for s = 1, capi.screen.count() do
    notifications[s] = {
        top_left = {},
        top_right = {},
        bottom_left = {},
        bottom_right = {},
    }
end

--- Suspend notifications
function suspend()
    suspended = true
end

--- Resume notifications
function resume()
    suspended = false
    for i, v in pairs(notifications.suspended) do
        v.box.visible = true
        if v.timer then v.timer:start() end
    end
    notifications.suspended = { }
end

--- Toggle notification state
function toggle()
    if suspended then
        resume()
    else
        suspend()
    end
end

-- Evaluate desired position of the notification by index - internal
-- @param idx Index of the notification
-- @param position top_right | top_left | bottom_right | bottom_left
-- @param height Popup height
-- @param width Popup width (optional)
-- @return Absolute position and index in { x = X, y = Y, idx = I } table
local function get_offset(screen, position, idx, width, height)
    local ws = capi.screen[screen].workarea
    local v = {}
    local idx = idx or #notifications[screen][position] + 1
    local width = width or notifications[screen][position][idx].width

    -- calculate x
    if position:match("left") then
        v.x = ws.x + config.padding
    else
        v.x = ws.x + ws.width - (width + config.padding)
    end

    -- calculate existing popups' height
    local existing = 0
    for i = 1, idx-1, 1 do
        existing = existing + notifications[screen][position][i].height + config.spacing
    end

    -- calculate y
    if position:match("top") then
        v.y = ws.y + config.padding + existing
    else
        v.y = ws.y + ws.height - (config.padding + height + existing)
    end

    -- if positioned outside workarea, destroy oldest popup and recalculate
    if v.y + height > ws.y + ws.height or v.y < ws.y then
        idx = idx - 1
        destroy(notifications[screen][position][1])
        v = get_offset(screen, position, idx, width, height)
    end
    if not v.idx then v.idx = idx end

    return v
end

-- Re-arrange notifications according to their position and index - internal
-- @return None
local function arrange(screen)
    for p,pos in pairs(notifications[screen]) do
        for i,notification in pairs(notifications[screen][p]) do
            local offset = get_offset(screen, p, i, notification.width, notification.height)
            notification.box:geometry({ x = offset.x, y = offset.y })
            notification.idx = offset.idx
        end
    end
end

--- Destroy notification by notification object
-- @param notification Notification object to be destroyed
-- @return True if the popup was successfully destroyed, nil otherwise
function destroy(notification)
    if notification and notification.box.screen then
        if suspended then
            for k, v in pairs(notifications.suspended) do
                if v.box == notification.box then
                    table.remove(notifications.suspended, k)
                    break
                end
            end
        end
        local scr = notification.box.screen
        table.remove(notifications[notification.box.screen][notification.position], notification.idx)
        if notification.timer then
            notification.timer:stop()
        end
        notification.box.screen = nil
        arrange(scr)
        return true
    end
end

-- Get notification by ID
-- @param id ID of the notification
-- @return notification object if it was found, nil otherwise
local function getById(id)
    -- iterate the notifications to get the notfications with the correct ID
    for s = 1, capi.screen.count() do
        for p,pos in pairs(notifications[s]) do
            for i,notification in pairs(notifications[s][p]) do
                if notification.id == id then
                    return notification
                 end
            end
        end
    end
end

--- Create notification. args is a dictionary of (optional) arguments.
-- @param text Text of the notification. Default: ''
-- @param title Title of the notification. Default: nil
-- @param timeout Time in seconds after which popup expires.
--   Set 0 for no timeout. Default: 5
-- @param hover_timeout Delay in seconds after which hovered popup disappears.
--   Default: nil
-- @param screen Target screen for the notification. Default: 1
-- @param position Corner of the workarea displaying the popups.
--   Values: "top_right" (default), "top_left", "bottom_left", "bottom_right".
-- @param ontop Boolean forcing popups to display on top. Default: true
-- @param height Popup height. Default: nil (auto)
-- @param width Popup width. Default: nil (auto)
-- @param font Notification font. Default: beautiful.font or awesome.font
-- @param icon Path to icon. Default: nil
-- @param icon_size Desired icon size in px. Default: nil
-- @param fg Foreground color. Default: beautiful.fg_focus or '#ffffff'
-- @param bg Background color. Default: beautiful.bg_focus or '#535d6c'
-- @param border_width Border width. Default: 1
-- @param border_color Border color.
--   Default: beautiful.border_focus or '#535d6c'
-- @param run Function to run on left click. Default: nil
-- @param preset Table with any of the above parameters. Note: Any parameters
--   specified directly in args will override ones defined in the preset.
-- @param replaces_id Replace the notification with the given ID
-- @param callback function that will be called with all arguments
--  the notification will only be displayed if the function returns true
--  note: this function is only relevant to notifications sent via dbus
-- @usage naughty.notify({ title = "Achtung!", text = "You're idling", timeout = 0 })
-- @return The notification object
function notify(args)
    if config.notify_callback then
        args = config.notify_callback(args)
        if not args then return end
    end

    -- gather variables together
    local preset = args.preset or config.default_preset or {}
    local timeout = args.timeout or preset.timeout or 5
    local icon = args.icon or preset.icon
    local icon_size = args.icon_size or preset.icon_size
    local text = args.text or preset.text or ""
    local title = args.title or preset.title
    local screen = args.screen or preset.screen or 1
    local ontop = args.ontop or preset.ontop or true
    local width = args.width or preset.width
    local height = args.height or preset.height
    local hover_timeout = args.hover_timeout or preset.hover_timeout
    local opacity = args.opacity or preset.opacity
    local margin = args.margin or preset.margin or "5"
    local border_width = args.border_width or preset.border_width or "1"
    local position = args.position or preset.position or "top_right"

    -- beautiful
    local beautiful = bt.get()
    local font = args.font or preset.font or beautiful.font or capi.awesome.font
    local fg = args.fg or preset.fg or beautiful.fg_normal or '#ffffff'
    local bg = args.bg or preset.bg or beautiful.bg_normal or '#535d6c'
    local border_color = args.border_color or preset.border_color or beautiful.bg_focus or '#535d6c'
    local notification = {}

    -- replace notification if needed
    if args.replaces_id then
        local obj = getById(args.replaces_id)
        if obj then
            -- destroy this and ...
            destroy(obj)
        end
        -- ... may use its ID
        if args.replaces_id < counter then
            notification.id = args.replaces_id
        else
            counter = counter + 1
            notification.id = counter
        end
    else
        -- get a brand new ID
        counter = counter + 1
        notification.id = counter
    end

    notification.position = position

    if title then title = title .. "\n" else title = "" end

    -- hook destroy
    local die = function () destroy(notification) end
    if timeout > 0 then
        local timer_die = capi.timer { timeout = timeout }
        timer_die:add_signal("timeout", die)
        if not suspended then
            timer_die:start()
        end
        notification.timer = timer_die
    end
    notification.die = die

    local run = function ()
        if args.run then
            args.run(notification)
        else
            die()
        end
    end

    local hover_destroy = function ()
        if hover_timeout == 0 then
            die()
        else
            if notification.timer then notification.timer:stop() end
            notification.timer = capi.timer { timeout = hover_timeout }
            notification.timer:add_signal("timeout", die)
            notification.timer:start()
        end
    end

    -- create textbox
    local textbox = capi.widget({ type = "textbox", align = "flex" })
    textbox:buttons(util.table.join(button({ }, 1, run), button({ }, 3, die)))
    layout.margins[textbox] = { right = margin, left = margin, bottom = margin, top = margin }
    textbox.valign = "middle"

    local function setText(pattern, replacements)
        textbox.text = string.format('<span font_desc="%s"><b>%s</b>%s</span>', font, title:gsub(pattern, replacements), text:gsub(pattern, replacements))
    end

    -- First try to set the text while only interpreting <br>.
    -- (Setting a textbox' .text to an invalid pattern throws a lua error)
    if not pcall(setText, "<br.->", "\n") then
        -- That failed, escape everything which might cause an error from pango
        if not pcall(setText, "[<>&]", { ['<'] = "&lt;", ['>'] = "&gt;", ['&'] = "&amp;" }) then
            textbox.text = "<i>&lt;Invalid markup, cannot display message&gt;</i>"
        end
    end

    -- create iconbox
    local iconbox = nil
    if icon then
        -- try to guess icon if the provided one is non-existent/readable
        if type(icon) == "string" and not util.file_readable(icon) then
            icon = util.geticonpath(icon, config.icon_formats, config.icon_dirs)
        end

        -- if we have an icon, use it
        if icon then
            iconbox = capi.widget({ type = "imagebox", align = "left" })
            layout.margins[iconbox] = { right = margin, left = margin, bottom = margin, top = margin }
            iconbox:buttons(util.table.join(button({ }, 1, run), button({ }, 3, die)))
            local img
            if type(icon) == "string" then
                img = capi.image(icon)
            else
                img = icon
            end
            if icon_size then
                img = img:crop_and_scale(0,0,img.height,img.width,icon_size,icon_size)
            end
            iconbox.resize = false
            iconbox.image = img
        end
    end

    -- create container wibox
    notification.box = capi.wibox({ fg = fg,
                                    bg = bg,
                                    border_color = border_color,
                                    border_width = border_width })

    if hover_timeout then notification.box:add_signal("mouse::enter", hover_destroy) end

    -- calculate the height
    if not height then
        if iconbox and iconbox:extents().height + 2 * margin > textbox:extents().height + 2 * margin then
            height = iconbox:extents().height + 2 * margin
        else
            height = textbox:extents().height + 2 * margin
        end
    end

    -- calculate the width
    if not width then
        width = textbox:extents().width + (iconbox and iconbox:extents().width + 2 * margin or 0) + 2 * margin
    end

    -- crop to workarea size if too big
    local workarea = capi.screen[screen].workarea
    if width > workarea.width - 2 * (border_width or 0) - 2 * (config.padding or 0) then
        width = workarea.width - 2 * (border_width or 0) - 2 * (config.padding or 0)
    end
    if height > workarea.height - 2 * (border_width or 0) - 2 * (config.padding or 0) then
        height = workarea.height - 2 * (border_width or 0) - 2 * (config.padding or 0)
    end

    -- set size in notification object
    notification.height = height + 2 * (border_width or 0)
    notification.width = width + 2 * (border_width or 0)

    -- position the wibox
    local offset = get_offset(screen, notification.position, nil, notification.width, notification.height)
    notification.box.ontop = ontop
    notification.box:geometry({ width = width,
                                height = height,
                                x = offset.x,
                                y = offset.y })
    notification.box.opacity = opacity
    notification.box.screen = screen
    notification.idx = offset.idx

    -- populate widgets
    notification.box.widgets = { iconbox, textbox, ["layout"] = layout.horizontal.leftright }

    -- insert the notification to the table
    table.insert(notifications[screen][notification.position], notification)

    if suspended then
        notification.box.visible = false
        table.insert(notifications.suspended, notification)
    end

    -- return the notification
    return notification
end

-- DBUS/Notification support
-- Notify
if capi.dbus then
    capi.dbus.add_signal("org.freedesktop.Notifications", function (data, appname, replaces_id, icon, title, text, actions, hints, expire)
    args = { preset = config.default_preset }
    if data.member == "Notify" then
        if text ~= "" then
            args.text = text
            if title ~= "" then
                args.title = title
            end
        else
            if title ~= "" then
                args.text = title
            else
                return
            end
        end
        for i, obj in pairs(config.mapping) do
            local filter, preset, s = obj[1], obj[2], 0
            if (not filter.urgency or filter.urgency == hints.urgency) and
               (not filter.category or filter.category == hints.category) and
               (not filter.appname or filter.appname == appname) then
                   args.preset = util.table.join(args.preset, preset)
            end
        end
        if not args.preset.callback or (type(args.preset.callback) == "function" and
            args.preset.callback(data, appname, replaces_id, icon, title, text, actions, hints, expire)) then
            if icon ~= "" then
                args.icon = icon
            elseif hints.icon_data or hints.image_data then
                if hints.icon_data == nil then hints.icon_data = hints.image_data end
                -- icon_data is an array:
                -- 1 -> width, 2 -> height, 3 -> rowstride, 4 -> has alpha
                -- 5 -> bits per sample, 6 -> channels, 7 -> data

                local imgdata = ""
                -- If has alpha (ARGB32)
                if hints.icon_data[6] == 4 then
                    for i = 1, #hints.icon_data[7], 4 do
                        imgdata = imgdata .. hints.icon_data[7]:sub(i, i + 2):reverse()
                        imgdata = imgdata .. hints.icon_data[7]:sub(i + 3, i + 3)
                    end
                -- If has not alpha (RGB24)
                elseif hints.icon_data[6] == 3 then
                    for i = 1, #hints.icon_data[7], 3 do
                        imgdata = imgdata .. hints.icon_data[7]:sub(i , i + 2):reverse()
                        imgdata = imgdata .. string.format("%c", 255) -- alpha is 255
                    end
                end
                if imgdata then
                    args.icon = capi.image.argb32(hints.icon_data[1], hints.icon_data[2], imgdata)
                end
            end
            if replaces_id and replaces_id ~= "" and replaces_id ~= 0 then
                args.replaces_id = replaces_id
            end
            if expire and expire > -1 then
                args.timeout = expire / 1000
            end
            local id = notify(args).id
            return "u", id
        end
        return "u", "0"
    elseif data.member == "CloseNotification" then
        local obj = getById(appname)
        if obj then
           destroy(obj)
        end
    elseif data.member == "GetServerInfo" or data.member == "GetServerInformation" then
        -- name of notification app, name of vender, version
        return "s", "naughty", "s", "awesome", "s", capi.awesome.version:match("%d.%d"), "s", "1.0"
    elseif data.member == "GetCapabilities" then
        -- We actually do display the body of the message, we support <b>, <i>
        -- and <u> in the body and we handle static (non-animated) icons.
        return "as", { "s", "body", "s", "body-markup", "s", "icon-static" }
    end
    end)

    capi.dbus.add_signal("org.freedesktop.DBus.Introspectable",
    function (data, text)
    if data.member == "Introspect" then
        local xml = [=[<!DOCTYPE node PUBLIC "-//freedesktop//DTD D-BUS Object
    Introspection 1.0//EN"
    "http://www.freedesktop.org/standards/dbus/1.0/introspect.dtd">
    <node>
      <interface name="org.freedesktop.DBus.Introspectable">
        <method name="Introspect">
          <arg name="data" direction="out" type="s"/>
        </method>
      </interface>
      <interface name="org.freedesktop.Notifications">
        <method name="GetCapabilities">
          <arg name="caps" type="as" direction="out"/>
        </method>
        <method name="CloseNotification">
          <arg name="id" type="u" direction="in"/>
        </method>
        <method name="Notify">
          <arg name="app_name" type="s" direction="in"/>
          <arg name="id" type="u" direction="in"/>
          <arg name="icon" type="s" direction="in"/>
          <arg name="summary" type="s" direction="in"/>
          <arg name="body" type="s" direction="in"/>
          <arg name="actions" type="as" direction="in"/>
          <arg name="hints" type="a{sv}" direction="in"/>
          <arg name="timeout" type="i" direction="in"/>
          <arg name="return_id" type="u" direction="out"/>
        </method>
        <method name="GetServerInformation">
          <arg name="return_name" type="s" direction="out"/>
          <arg name="return_vendor" type="s" direction="out"/>
          <arg name="return_version" type="s" direction="out"/>
          <arg name="return_spec_version" type="s" direction="out"/>
        </method>
        <method name="GetServerInfo">
          <arg name="return_name" type="s" direction="out"/>
          <arg name="return_vendor" type="s" direction="out"/>
          <arg name="return_version" type="s" direction="out"/>
       </method>
      </interface>
    </node>]=]
        return "s", xml
    end
    end)

    -- listen for dbus notification requests
    capi.dbus.request_name("session", "org.freedesktop.Notifications")
end

-- vim: filetype=lua:expandtab:shiftwidth=4:tabstop=8:softtabstop=4:textwidth=80
